Skip to content

fix: resolve CSS-wide keywords and the inherited colour as CSS defines them - #391

Draft
YevheniiKotyrlo wants to merge 28 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/color-inherit
Draft

YevheniiKotyrlo wants to merge 28 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/color-inherit

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On native, color: inherit, and so Tailwind's text-inherit, is dropped with a warning, so a label under a coloured button falls back to React Native's default colour where web renders the parent's. The CSS-wide keywords around it diverge from CSS in the same path:

  1. unset on color is dropped, and currentcolor inside color reads the element's own colour, so color: currentcolor resolves into itself.
  2. revert and revert-layer reach the style as strings, and initial is dropped, so an earlier rule's value survives.
  3. A keyword inside a longer value is accepted: box-shadow: 1px 1px inherit clears an earlier shadow and object-position: left inherit applies left.
  4. A custom property holding unset publishes the literal, inherit and initial are dropped, a keyword fallback is dropped, and an env() fallback is parsed under the variable's name.
  5. Keywords are compared as written, so INHERIT misses.
  6. A keyword that clears a shorthand leaves its longhands, and an earlier rule's animation keeps running.
  7. A shorthand that fails to parse clears an earlier rule's value.
  8. A colour rule that loses to a later color: inherit still publishes its colour to the subtree.
  9. A declaration whose var() resolves to nothing is left out where browsers make it unset, and inside a list the entry is dropped.
  10. The compiler substitutes a variable read inside a function before flattening it, folds a cycle into itself, and nests a var() holding several transforms.

Solution

  • A CSS-wide keyword counts only as a declaration's whole value, or as the fallback of a var() or env() that is the whole value (css-values-4 §2.1); inside a longer value the declaration is ignored.
  • revert and revert-layer roll back to unset, since React Native has no user-agent or user origin (css-cascade-5 §7.3.4); unset is inherit on color and on custom properties and initial elsewhere (§7.3.3).
  • inherit on color reads the inherited colour; on a custom property it is the parent's value, read over every declaration on the element (css-variables-1 §2); elsewhere it is dropped with a warning. initial clears the property, which leaves React Native's own default in place, and on a custom property it is the guaranteed-invalid value.
  • A var() with no value and no fallback leaves its declaration invalid at computed-value time wherever it sits (css-variables-1 §3.1), so the declaration behaves as unset. An empty fallback substitutes nothing.
  • The compiler flattens every variable a value reads before substituting it, leaves a cycle to the runtime, and compiles a substitution that reads the guaranteed-invalid value as unset.
  • currentcolor inside color resolves against the parent (css-color-4 §6.4), and elsewhere reads the element's own colour.
  • An element publishes its colour to its subtree as declared, in each rule that applies it, light-dark()'s dark rule included, and links the variables it hands down to the ones it inherited. Every other copy of those variables keeps the link, another element's, VariableContextProvider's and vars()'s, so a currentcolor the published colour reads, written or through a variable, resolves against the publisher's parent wherever the reader sits: a child of color: var(--c) with --c: currentcolor inherits the parent's colour, and a child of color: color-mix(in srgb, currentcolor, blue) inherits the mix. A published colour invalid at computed-value time is unset, so its subtree inherits what the element inherited. A reader renders again when that colour changes.
  • A shorthand cleared by a keyword resets every longhand it sets, and a declaration that fails to parse is left out.
  • Keywords are lowercased, light-dark() is parsed once, each declaration keeps its own target, and var(--constructor) takes its fallback.

Tests

compiler.test.tsx, colors.test.tsx, keywords.test.tsx, variables.test.tsx, transform.test.tsx, filter-list-runtime.test.tsx, animations.test.tsx and Tailwind's typography.test.tsx cover each keyword on color, custom properties, plain properties and shorthands, as a whole value, nested and as a fallback, on the inlined and the kept route, a colour read through a kept variable, a subtree following an ancestor's change, a published colour reaching a child through an element or a provider that publishes none, and light-dark() in dark mode. On main, 259 of the 498 cases fail; all pass here, and undoing each part of the change fails its own cases. Every expectation is what Chromium, Firefox and WebKit compute, and the three agree (153, 155 and 26.6; 156, 157 and 27.2 for the published colour).

Verification

Every job of the workflow passes on this branch merged into main: build, lint, typecheck, yarn test, node --test, both library builds, the web export, commitlint, and the iOS dev build with Xcode 27.1, where CI pins 26.6.

Device evidence, before and after: #391 (comment)

Related

#464 keeps color-mix() intact through the JSON a device loads its stylesheet as, which the color-mix() of currentcolor cases need there. #503, stacked on this branch, keeps a reader's fallback where an inlined value still reads a variable only the runtime resolves. #389 resolves a translate held in a variable, and #411 scopes a pseudo-element's custom properties to it, which supersedes the pseudo-element change here.

#501 builds on this and #474, so both land first. #392 seeds the root colour that the colors.test.tsx and typography.test.tsx cases expecting main's PlatformColor seed read, so whichever of the two lands second updates them. This overlaps #413, #420, #421, #460 and #469, and shares native/styles/functions/filters.ts with #489, which reads text-shadow without shorthandHandler; I'll re-cut whichever lands after the others, carrying this reset of a cleared shorthand into text-shadow.ts if #489 lands first.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review July 26, 2026 14:53
mubeess added a commit to mubeess/react-native-css that referenced this pull request Jul 31, 2026
- Hoist allowed Set to module scope (no per-call realloc)
- Comment lightningcss case normalization and nativewind#391 asymmetry
- Add negative test: match-parent drops with warning (no over-match)
- Document native View->Text inheritance caveat in README
@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The text-inherit child paints the default foreground, both --brand rows keep the earlier blue, the var(--missing) row loses its colour, and the three animation subjects hold the earlier animation's blue.

FIXED — Every inheriting row is the same red as the control beneath it, the --brand: initial row takes its green fallback, and the three animation subjects are red like animation: none, as browsers compute.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run; each --brand and var(--missing) row sits under a parent setting color and --brand to red, and each animation row is a red box an earlier rule animates to blue and holds there. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

`color: inherit`, `unset` and `currentcolor` compile to a read of the
colour every `color` rule publishes, and a colour that reads that
channel is not published back into it. While an element computes its
own `color`, the read resolves in the scope it inherited, because
css-color-4 makes `currentcolor` the inherited colour there; on every
other property it stays the element's own colour. nativewind#451 ordered the
element's own variables before inherited ones, so the inherited read is
now explicit rather than a consequence of lookup order.
The compiler and the resolver each asked whether a var() reads the
inherited colour, under one name with two meanings. The shallow question
is now `isInheritedColorLookup` beside the variable it names, and the
compiler's walk calls it for each var() it meets. Each constraint is stated
in one line, citing the sections that state it.
No caller passes parseUnparsed a string or a number: with either removed from the parameter
type, tsc reports no call site, and the test suite never takes either branch. The string
branch's `currentcolor` arm was a third spelling of the inherited-colour lookup that nothing
reached.
Each expected value is what Chromium, Firefox and WebKit compute for the same CSS. The cases
that pinned a divergence are gone: a light-dark() descendant in dark mode, a custom property
holding `inherit` that is not inlined, and the string relative colour syntax resolves to.
`::selection` is left to the pseudo-element mapping, and `::placeholder` also reads the
element's own colour.
A CSS-wide keyword is a value only on its own (css-values-4 §2.1), so the compiler resolves
one where it is a declaration's whole value or a var() fallback's, and rejects one inside a
longer value. `revert` and `revert-layer` roll back to `unset`, as React Native has no
user-agent or user origin. `unset` is `inherit` on `color` and on a custom property and
`initial` elsewhere, and `initial` clears the property to React Native's default. A custom
property left to inherit publishes nothing, so a read finds its ancestor's value, and its
warnings now name it.

A shorthand cleared by a keyword resets every longhand it sets, and a cleared filter is none.

An element's inherited colour comes from its last rule that publishes one, so a later `color`
that inherits no longer leaves an earlier rule's colour for its subtree, and a pseudo-element's
colour stays its own.
The first pass prints the stylesheet before the second parses it, so no white space from a
comment reaches the keyword, and the filter for it was unreachable.
A shorthand parsed at runtime was registered with an empty argument when its value failed to
parse, which cleared an earlier rule's value; browsers ignore an invalid declaration, so it is
left out. The CSS-wide keyword cases join the existing keyword suite.
… none

A filter or transform list that a var() fills with `none`, a cleared value or `initial` is
`none` alone or invalid at computed-value time, and either way the property is `none`; the
resolvers returned the keyword inside the list.
…rive cleared

Only a shorthand that returns its longhands as an object receives a cleared argument: animation
wraps its argument in a list, and box-shadow and drop-shadow handle it before the handler.
…he declaration

A keyword is a value only on its own, so one inside a function, a colour or a list makes the
whole declaration invalid; rejecting the token alone left `box-shadow: 1px 1px inherit` as a
shadow and `blur(inherit)` as an empty blur. A var() or env() defers its value, and a custom
property's value is any tokens, so neither is checked.
…CSS-wide keyword whole

These three parse their own value, so a keyword inside it was dropped as a token and the rest
applied: `object-position: left inherit` set `left`. They now share the declaration's keyword
decision, so the declaration is invalid and an earlier rule's value stands, as it does in
Chromium 153, Firefox 155 and WebKit 26.6.
… the function is

A fallback replaces its var() or env(), so a CSS-wide keyword in it applies only when the
function is the declaration's whole value (css-variables-1 §3). Inside a longer value the keyword
leaves the declaration invalid at computed-value time, yet it was resolved everywhere:
`color-mix(in srgb, var(--missing, inherit), blue)` mixed the inherited colour with blue, and
`translateX(var(--missing, initial))` compiled `unset` into the function. Inside a longer value
the fallback is now dropped with a warning.

An env() fallback is parsed for the declaring property, as a var() fallback is, rather than
under the env() name.
A keyword on its own was reported lowercased, while a nested one and every other value warning
quote the value as the stylesheet spells it.
A custom property set to inherit, unset, revert or revert-layer was
dropped, so an earlier rule's value on the same element won, and one
set to initial was dropped with a warning and read the ancestor's
value. Each now compiles to a value the runtime resolves where the
property is declared: inherit reads the parent's value over every
declaration on the element, and initial is the guaranteed-invalid
value, which takes a var() fallback and hides an ancestor's
declaration (css-variables-1 §2).

A declaration a var() leaves without a value is invalid at
computed-value time and behaves as unset (css-variables-1 §3.1): color
inherits, a runtime-parsed shorthand resets its longhands, and any
other property is cleared. A filter list with such an entry has no
filter, an empty fallback substitutes nothing, and the inliner never
folds an inheriting keyword and compiles a read of an inlined initial
the same way. A context lookup reads only a variable's own entries, so
var(--constructor) takes its fallback.
…it sits

A var() with no fallback for a variable that has no value left its
declaration a value when it sat inside a list, a function or another
custom property: `--reader: var(--source) 1px` read `1px`, and a
custom property whose value failed fell back to its ancestor's
declaration instead of the reader's fallback. The lookup now records
the failed substitution itself, so the declaration is invalid at
computed-value time and a custom property holding one is the
guaranteed-invalid value (css-variables-1 §3.1). A channel rncss
publishes for a computed value, such as the em, is a default when
nothing publishes it, never a failure.

The inliner flattens every variable a value reads, inside functions
and fallbacks too, before substituting it, so the result no longer
depends on declaration order, and leaves each member of a cycle to the
runtime. A custom property reading the guaranteed-invalid value is
guaranteed-invalid itself, a declaration whose value substitutes to
nothing is unset, and an empty custom property keeps its empty value,
which substitutes nothing inside a list at runtime as well.
…me too

The compiler made a declaration whose value substitutes to nothing
unset, while the runtime handed React Native the empty list it left:
`margin: var(--a,) var(--b,)` with neither declared rendered
`margin: []` over an earlier rule's value. The list resolver records
when every entry substituted nothing, which leaves a property's value
invalid at computed-value time and a custom property's value empty, as
css-variables-1 §3.1 defines.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 17, 2026 00:11
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 17, 2026 01:09
The animation shorthand answered `unset` with nothing, so `animation: initial`,
`animation: unset` and `animation: var(--missing)` left an earlier rule's
animation running. It now resets every longhand it sets, as the other
shorthands do.
…ts element inherited

An element publishes its `color` to its subtree as the declaration it
wrote, over the colour it inherited. When that declaration reads
`currentcolor` through a variable, as `color: var(--c)` with
`--c: currentcolor` does, a descendant that resolves the publication
reaches the inherited-colour lookup, which reads the same publication
again, so the colour cycles and the descendant gets none. CSS hands the
descendant the element's computed colour, which is what it inherited.

The variables an element hands its subtree now keep the ones it
inherited, and a lookup reached while resolving a published colour reads
the colour from there, one scope up. A reader guards that link, so it
renders again when an ancestor's colour changes. The compiler no longer
publishes the bare lookup for a colour that reads the inherited one, so
a child of `color: color-mix(in srgb, currentcolor, blue)` inherits the
mix, as CSS computes it.
The variables an element handed its subtree linked the ones it inherited
whenever it declared a variable, and the copies `VariableContextProvider`
and `vars()` make dropped the link. Below an element that declares a
variable but publishes no colour, a child of
`color: color-mix(in srgb, currentcolor, blue)` resolved the mix against
the mix itself and mixed twice; below a provider it got no colour. The
link is now set where an element publishes its colour and carried
through every other copy, so `currentcolor` inside a published colour
reads what its publisher inherited wherever the reader sits.

The rule `light-dark()` adds for its dark value kept the variables of
the rule it came from, so in dark mode it published the light colour and
a child inherited that. It now publishes the dark colour it applies.

A published colour invalid at computed-value time is `unset`, so its
subtree inherits what the element inherited, as CSS computes it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant